#!/usr/bin/env python

import random
import math
import copy

def main():
    pass


def euclidianDist(v1, v2):
    squaredDist = 0
    for i in range(len(v1)):
        squaredDist += (v1[i] - v2[i]) ** 2
    return squaredDist ** 0.5


def getRandomRGB():
    r = random.randint(0, 255)
    g = random.randint(0, 255)
    b = random.randint(0, 255)
    return (r, g, b)


def getColor(rgb):
    r, g, b = map(round, rgb)
    r = hex(r)[2:].zfill(2)
    g = hex(g)[2:].zfill(2)
    b = hex(b)[2:].zfill(2)
    return '#' + r + g + b


def generateInputData(genFunc, n):
    dataset = []
    for i in range(n):
        dataset.append(genFunc())
    return dataset


def getVectorAsStr(vector):
        if vector is None:
            return None
        vStr = '(' + str(round(vector[0], 2))
        for e in vector[1:]:
            vStr += ', ' + str(round(e, 2))
        vStr += ')'
        return vStr


class Node():
    def __init__(self, row = None, col = None, weight = (), distToInput = float('inf')):
        self.row = row
        self.col = col
        self.weight = weight
        self.distToInput = distToInput
        self.mappings = []


    def __repr__(self):
        return self.__class__.__name__ + repr((self.row, self.col))


    def __getitem__(self, index):
        return self.weight[index]


    def __iter__(self):
        for attr in (self.row, self.col):
            yield attr


    def getWeightAsStr(self):
        return getVectorAsStr(self.weight)


    def getMappings(self):
        mappings = []
        for m in self.mappings:
            mappings.append(getVectorAsStr(m))
        return tuple(mappings)


    def copy(self):
        return copy.deepcopy(self)


    def getNodeDist(self, node):
        v1 = (self.row, self.col)
        v2 = (node.row, node.col)
        return euclidianDist(v1, v2)


    def adjustWeight(self, inputVector, adjustFactor):
        delta = map(lambda x: x[0] - x[1], zip(inputVector, self.weight))
        adjustedDelta = map(lambda x: x * adjustFactor, delta)
        self.weight = tuple(map(sum, zip(self.weight, adjustedDelta)))



class HistoryPoint():
    def __init__(self, nodes, configuration):
        self.nodes = nodes
        self.configuration = configuration



class SelfOrgMap():
    def __init__(self, randWeightFunc, distFunc = euclidianDist):
        self.config(**self.getDefaultConfig())
        self.nodes = []
        self.BMU = None
        self.nextInput = None
        self.randWeightFunc = randWeightFunc
        self.distFunc = distFunc    # funtion for calculating distance between two nodes
        self.history = []


    def __iter__(self):
        for n in self.nodes:
            yield n


    def __len__(self):
        return (self.nodes is not None and len(self.nodes)) or 0


    def getDefaultConfig(self):
        return {'rows': 20,
                'cols': 20,
                'lrate': 0.5,
                'lrateDecay': 0.5,
                'nhood': 5,
                'nhDissipation': 0.5,
                'nhoodDecay': 1,
                'maxScans': 10,
                'inputSizeFactor': 5}


    def config(self, **kwargs):
        '''
        Valid keywords:
            rows - number of rows map has
            cols - number of columns map has
            lrate - initial learning rate
            lrateDecay - factor for decreasing lrate after each scan
            nhood - a node is in BMU's neighborhood if its distance to BMU is less than or equal to nhood units
            nhDissipation - factor for adjusting how strongly are more distant neighbors of BMU affected
            nhoodDecay - factor for decreasing nhood after each scan
            maxScans - maximum numbers of passes through input data in training phase
            inputSizeFactor - number of input vectors = number of nodes * inputSizeFactor
        '''
        validKeys = ['rows', 'cols', 'lrate', 'lrateDecay', 'nhood', 'nhDissipation', 'nhoodDecay', 'maxScans', 'inputSizeFactor']
        for k, v in kwargs.items():
            if k in validKeys:
                setattr(self, k, v)


    def makeNodes(self, rows, cols, randWeightFunc):
        nodes = []
        for row in range(rows):
            for col in range(cols):
                nodes.append(Node(row, col, weight = randWeightFunc()))
        return nodes


    def getNode(self, row, col):
        idx = (self.cols * row) + col
        if idx >= len(self.nodes):
            return None
        return self.nodes[idx]


    def findBMU(self, v):
        self.BMU = Node()
        for node in self.nodes:
            dist = self.distFunc(v, node.weight)
            node.distToInput = dist
            if dist < self.BMU.distToInput:
                self.BMU = node


    def getNeighborhood(self, centerNode):
        if self.nhood < 1:
            return None
        cnRow, cnCol = centerNode
        neighbors = []
        for node in self:
            if id(node) == id(centerNode):
                continue
            if node.getNodeDist(centerNode) <= self.nhood:
                neighbors.append(node)
        return neighbors


    def getBMUNeighborhood(self):
        return self.getNeighborhood(self.BMU)


    def findNodesToUpdate(self):
        nodesToUpdate = [self.BMU]
        neighbors = self.getBMUNeighborhood()
        if neighbors is not None:
            nodesToUpdate += neighbors
        return nodesToUpdate


    def updateWeights(self, nodesToUpdate, inputVector):
        for node in nodesToUpdate:
            nfactor = 1 / (1 + self.nhDissipation * node.getNodeDist(self.BMU) ** 2)
            adjustFactor = self.lrate * nfactor
            node.adjustWeight(inputVector, adjustFactor)
        return nodesToUpdate


    def initLearning(self, inputSet = None):
        self.nodes = self.makeNodes(self.rows, self.cols, self.randWeightFunc)
        self.paused = True
        self.doneLearning = False
        if inputSet is None:
             self.inputSet = generateInputData(getRandomRGB, len(self) * self.inputSizeFactor)
        else:
            self.inputSet = inputSet
        self.inputIdx = 0
        self.scan = 0
        self.iteration = 0
        self.nextInput = self.inputSet[self.inputIdx]
        self.findBMU(self.nextInput)


    def nextScan(self):
        self.inputIdx = 0
        self.scan += 1
        self.lrate *= self.lrateDecay
        self.nhood *= self.nhoodDecay
        if self.scan == self.maxScans:
            self.doneLearning = True


    def saveHistoryPoint(self, changedNodes):
        # push current state to history (configs and weights)
        configuration = {}
        configuration['inputIdx'] = self.inputIdx
        configuration['iteration'] = self.iteration
        configuration['scan'] = self.scan
        configuration['lrate'] = self.lrate
        configuration['nhood'] = self.nhood
        configuration['BMU'] = self.BMU
        configuration['nextInput'] = self.nextInput

        hNodes = []
        for node in changedNodes:
            hNodes.append((node.row, node.col, node.weight))

        self.history.append(HistoryPoint(hNodes, configuration))

        # size limit for history to save memory
        if len(self.history) > 100:
            self.history.pop(0)


    def nextIteration(self):
        if self.doneLearning:
            return []
        nodesToUpdate = self.findNodesToUpdate()
        self.saveHistoryPoint(nodesToUpdate) # save current state to history
        updatedNodes = self.updateWeights(nodesToUpdate, self.nextInput)
        self.nextInput = self.inputSet[self.inputIdx]
        self.findBMU(self.nextInput)
        self.iteration += 1
        self.inputIdx += 1
        if self.inputIdx >= len(self.inputSet):
            self.nextScan()
        return updatedNodes


    def previousIteration(self):
        if self.doneLearning:
            return []
        if not self.history:
            return []
        historyPoint = self.history.pop()
        nodesToRestore = []
        for row, col, weight in historyPoint.nodes:
            node = self.getNode(row, col)
            node.weight = weight
            nodesToRestore.append(node)

        self.inputIdx = historyPoint.configuration['inputIdx']
        self.iteration = historyPoint.configuration['iteration']
        self.scan = historyPoint.configuration['scan']
        self.lrate = historyPoint.configuration['lrate']
        self.nhood = historyPoint.configuration['nhood']
        self.BMU = historyPoint.configuration['BMU']
        self.nextInput = historyPoint.configuration['nextInput']

        return nodesToRestore


    def mapInput(self, inVector):
        self.findBMU(inVector)
        self.BMU.mappings.append(inVector)
        return self.BMU


if __name__ == '__main__':
    main()
